Colocaciones y métricas de asociación

En este notebook miraremos cómo detectar posibles colocaciones de forma estadística.


In [ ]:
import nltk
#nltk.download('book')

Empezamos como siempre con un texto


In [ ]:
text = '''New York is a state in the Northeastern United States and is the 27th-most extensive, \
fourth-most populous, and seventh-most densely populated U.S. state. New York is bordered by \
New Jersey and Pennsylvania to the south and Connecticut, Massachusetts, and Vermont to the east. \
The state has a maritime border in the Atlantic Ocean with Rhode Island, east of Long Island, \
as well as an international border with the Canadian provinces of Quebec to the north and Ontario \
to the west and north. The state of New York, with an estimated 19.8 million residents in 2015, \
is often referred to as New York State to distinguish it from New York City, the state's most \
populous city and its economic hub.

With an estimated population of nearly 8.5 million in 2014, New York City is the most populous \
city in the United States and the premier gateway for legal immigration to the United States. \
The New York City Metropolitan Area is one of the most populous urban agglomerations in the world. \
New York City is a global city, exerting a significant impact upon commerce, finance, media, \
art, fashion, research, technology, education, and entertainment, its fast pace defining the \
term New York minute. The home of the United Nations Headquarters, New York City is an important \
center for international diplomacy and has been described as the cultural and financial capital \
of the world, as well as the world's most economically powerful city. New York \
City makes up over 40% of the population of New York State. Two-thirds of the state's population \
lives in the New York City Metropolitan Area, and nearly 40% live on Long Island. Both the state \
and New York City were named for the 17th century Duke of York, future King James II of England. \
The next four most populous cities in the state are Buffalo, Rochester, Yonkers, and Syracuse, \
while the state capital is Albany.

The earliest Europeans in New York were French colonists and Jesuit missionaries who arrived \
southward from settlements at Montreal for trade and proselytizing. New York had been inhabited \
by tribes of Algonquian and Iroquoian-speaking Native Americans for several hundred years by the \
time Dutch settlers moved into the region in the early 17th century. In 1609, the region was first \
claimed by Henry Hudson for the Dutch, who built Fort Nassau in 1614 at the confluence of the \
Hudson and Mohawk rivers, where the present-day capital of Albany later developed. The Dutch soon \
also settled New Amsterdam and parts of the Hudson Valley, establishing the colony of New \
Netherland, a multicultural community from its earliest days and a center of trade and immigration. \
The British annexed the colony from the Dutch in 1664. The borders of the British colony, the \
Province of New York, were similar to those of the present-day state.'''

N-gramas

Para detectar colocaciones primero necesitamos las posibles combinaciones de palabras que aparecen en el texto. Nos limitamos aquí a secuencias de dos palabras consecutivas, llamadas bigramas.

Convertimos el texto en bigramas:


In [ ]:
words = nltk.word_tokenize(text)
bigrams = list(nltk.bigrams(words))

In [ ]:
bigrams

Podemos ver la distribución de frecuencias de estos bigramas:


In [ ]:
fd = nltk.FreqDist(bigrams)

In [ ]:
fd

In [ ]:
fd.most_common()

Ahora con un corpus grande

En textos cortos claramente no tenemos suficiente información para detectar colocaciones de forma general, por lo tanto necesitamos un corpus grande (cuanto más grande, mejor).

Usamos el Brown corpus que es parte del paquete 'book' de NTLK, por lo que ya lo tenemos descargado en nuestro workspace.


In [ ]:
from nltk.corpus import brown

Este corpus ya contiene el texto tokenizado:


In [ ]:
brown.words()

Y podemos ver que es bastante grande:


In [ ]:
total_words_brown = len(brown.words())

In [ ]:
total_words_brown

Podemos obtener la lista de bigramas de la misma forma que con nuestro propio texto. Usamos la función list() para asegurarnos que el resultado sea una lista, y que podamos trabajar como en los ejercicios anteriores.


In [ ]:
bg = list(nltk.bigrams(brown.words()))

Vemos los 20 primeros bigramas de la lista:


In [ ]:
bg[:20]

Y ahora miramos los bigramas más frecuentes:


In [ ]:
bigram_dist = nltk.FreqDist(bg)

In [ ]:
bigram_dist.most_common()

Vemos que no son muy útiles, solo contienen palabras muy comunes. Tendremos que usar otra métrica.

Métricas de asociación

NLTK proporciona varias métricas de asociación, parecidas o idénticas a las que encontramos en BNCweb:


In [ ]:
from nltk.metrics.association import BigramAssocMeasures

Las métricas se calculan en base a los siguientes valores (con los nombres de las variables que usaremos para referirnos a ellos):

  • la frecuencia de coocurencia de las dos palabras (es decir la frecuencia del bigrama): co_freq
  • la frecuencia de cada una de las palabras de forma independiente: w1_freq y w2_freq
  • el tamaño de la colección: total_words_bnc

En BNCweb, buscando colocaciones para 'water', únicamente justo a la izquierda (donde suelen estar los adjetivos que lo modificarían), encontramos p.ej. los siguientes valores para la colocación con "boiling":


In [ ]:
co_freq = 233 # frecuencia del bigrama "boiling water"
w1_freq = 881 # frecuencia de "boiling"
w2_freq = 34325 # frecuencia de "water"
total_words_bnc = 98313429 # tamaño del BNC

Le aplicamos la métrica pmi que corresponde a la "Mutual Information" en BNCweb.

Se calcula según esta fórmula: $$ PMI = \log(\text{co_freq} * \text{total}) - \log(\text{w1_freq} \cdot \text{w1_freq})$$

o de forma más general: $$PMI = \log {p(w1,w2) \over p(w1) p(w2)}$$

es decir la probabilidad que las dos palabras aparecen juntas, comparado con la probabilidad si fueran independientes ( lo que corresponde a la "Expected collocate frequency" en BNCweb).


In [ ]:
BigramAssocMeasures.pmi(co_freq,(w1_freq,w2_freq), total_words_bnc)

Y hacemos lo mismo con la métrica likelihood_ratio ("Log-likelihood" en BNC). La fórmula en este caso es un poco más complicada, así que nos la saltamos.


In [ ]:
BigramAssocMeasures.likelihood_ratio(co_freq,(w1_freq,w2_freq), total_words_bnc)

Podemos ver que conseguimos resultados muy parecidos a los que nos da BNCweb. Hay una pequeña diferencia porque hay diferentes variantes y aproximaciones al hacer el cálculo, pero no es importante para nosotros.

Ranking de colocaciones

Ahora aplicaremos estas métricas a todos los bigramas que hemos encontrado para detectar los bigramas con más posibilidad de ser colocaciones (o por lo menos combinaciones interesantes).

Ya tenemos la frecuencia de cada bigrama y el tamaño total del corpus, por lo que nos falta la frecuencia de cada palabra por separado:


In [ ]:
word_dist = nltk.FreqDist(brown.words())

In [ ]:
word_dist.most_common()

Y ahora lo aplicamos a toda la colección. Creamos una lista que para cada bigrama distinto de nuestro texto (que obtenemos con bigram_dist.keys()) contiene:

  • el bigrama mismo, p.ej. ('New', 'York')
  • su valor de información mútua,

sabiendo que el valor de información mútua se basa en:

  • la frecuencia del bigrama: bigram_dist[bigram]
  • la frecuencia de cada palabra: word_dist[bigram[0]] y word_dist[bigram[1]]
  • el tamaño del corpus: total_words_brown

In [ ]:
pmi_list = [(bigram,
             BigramAssocMeasures.pmi(bigram_dist[bigram],
                                     (word_dist[bigram[0]],word_dist[bigram[1]]),
                                     total_words_brown)
            ) for bigram in bigram_dist.keys()]

Ordenamos la lista para ver los bigramas con la asociación más fuerte. sorted(mi_lista) devuelve la lista de forma ordenada, y key=lambda x: -x[1] significa que lo queremos ordenador por por el valor del segundo elemento (es decir el PMI) de mayor a menor.


In [ ]:
pmi_list.sort(key=lambda x: -x[1])

Y miramos los 20 primeros bigramas:


In [ ]:
pmi_list[:20]

Para evitar bigramas que casi nunca aparecen en el corpus podemos filtrar la lista (mínimo tres co-occurrencias):


In [ ]:
pmi_list_filtered = [(bigram,pmi) for bigram,pmi in pmi_list if bigram_dist[bigram] >= 3]

In [ ]:
pmi_list_filtered[:20]

Y mirando solo las colocaciones de una palabra concreta:


In [ ]:
pmi_list_good = [(bigram,pmi) for bigram,pmi in pmi_list_filtered if bigram[0]=='good']

In [ ]:
pmi_list_good

In [ ]: